This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / routes / [handle] / +page.ts
11 kB 342 lines
1import type { Did } from "@atcute/lexicons/syntax"; 2import { createBobbinClient } from "$lib/api/client"; 3import { fetchPage } from "$lib/api/pagination"; 4import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich"; 5import { type RepoRecord, type RecordList } from "$lib/api/records"; 6import { IdentityCache } from "$lib/api/identity"; 7import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 8import { toHttpError } from "$lib/api/load"; 9import type { SearchPage } from "$lib/api/search"; 10import type { BobbinContext } from "$lib/api/client"; 11import { type VouchRecord } from "$lib/api/graph"; 12import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 13import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 14import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; 15import type { 16 RepoCardData, 17 StringCardData, 18 PersonData, 19 VouchData, 20 StarData 21} from "$lib/components/profile/types"; 22import type { PageLoad } from "./$types"; 23 24const PAGE_LIMIT = 50; 25 26const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" }; 27const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" }; 28const FOLLOW_STATS: LinkDescriptor[] = [ 29 { source: "sh.tangled.graph.follow:subject", type: "count" }, 30 { source: "sh.tangled.graph.follow:.repo", type: "count" } 31]; 32const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" }; 33 34const TABS = [ 35 "overview", 36 "repos", 37 "starred", 38 "strings", 39 "followers", 40 "following", 41 "vouches" 42] as const; 43type Tab = (typeof TABS)[number]; 44 45const normalizeTab = (raw: string | null): Tab => 46 TABS.includes(raw as Tab) ? (raw as Tab) : "overview"; 47 48interface ListItem { 49 uri: string; 50 value: unknown; 51} 52 53const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 54 const value = item.value as RepoRecord; 55 return { 56 rkey: rkeyFromUri(item.uri), 57 name: rkeyFromUri(item.uri), 58 repoDid: value.repoDid ?? "", 59 ownerHandle, 60 description: value.description, 61 knot: value.knot, 62 createdAt: value.createdAt 63 }; 64}; 65 66const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => { 67 const repo = toRepoCard(item, ownerHandle); 68 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 69 const stars = countOf(stats, repo.repoDid, STAR_COUNT.source); 70 const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source); 71 return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri }; 72}; 73 74const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 75 const value = item.value as ShTangledString.Main; 76 return { 77 rkey: rkeyFromUri(item.uri), 78 ownerHandle, 79 filename: value.filename, 80 description: value.description, 81 createdAt: value.createdAt, 82 lines: value.contents?.split("\n").length ?? 1 83 }; 84}; 85 86// the sidecar already carries follower counts and viewer status, so this costs 87// no extra requests 88const resolvePeople = async ( 89 ctx: BobbinContext, 90 dids: string[], 91 stats: Stats, 92 viewerDid?: string 93): Promise<PersonData[]> => { 94 const cache = new IdentityCache(ctx); 95 const unique = [...new Set(dids)]; 96 97 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 98 99 const byDid = new Map<string, PersonData>(); 100 unique.forEach((did, index) => { 101 const doc = docs[index]; 102 const followers = countOf(stats, did, "sh.tangled.graph.follow:subject"); 103 const following = countOf(stats, did, "sh.tangled.graph.follow:.repo"); 104 const isSelf = viewerDid === did; 105 const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source); 106 const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri; 107 byDid.set( 108 did, 109 doc 110 ? { 111 did: doc.did, 112 handle: doc.handle, 113 followers, 114 following, 115 isSelf, 116 viewerFollowRkey 117 } 118 : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 119 ); 120 }); 121 return unique.map((did) => byDid.get(did) as PersonData); 122}; 123 124const resolveVouches = async ( 125 ctx: BobbinContext, 126 items: ListItem[], 127 direction: "incoming" | "outgoing" 128): Promise<VouchData[]> => { 129 const cache = new IdentityCache(ctx); 130 return Promise.all( 131 items.map(async (item): Promise<VouchData> => { 132 const value = item.value as VouchRecord; 133 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); 134 const doc = await cache.resolve(otherDid).catch(() => null); 135 return { 136 uri: item.uri, 137 did: otherDid, 138 handle: doc?.handle ?? otherDid, 139 kind: value.kind === "denounce" ? "denounce" : "vouch", 140 direction, 141 reason: value.reason, 142 createdAt: value.createdAt 143 }; 144 }) 145 ); 146}; 147 148const resolveStars = async ( 149 ctx: BobbinContext, 150 items: ListItem[], 151 viewerDid?: string 152): Promise<StarData[]> => { 153 const cache = new IdentityCache(ctx); 154 const repoDids = [ 155 ...new Set( 156 items 157 .map((item) => (item.value as ShTangledFeedStar.Main).subject) 158 .flatMap((s) => (s && "did" in s && s.did ? [s.did] : [])) 159 ) 160 ]; 161 const enriched = 162 repoDids.length > 0 163 ? await enrich<RecordList<RepoRecord>>(ctx, { 164 xrpc: "sh.tangled.repo.getReposByRepoDids", 165 params: { dids: repoDids }, 166 enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], 167 ...(viewerDid ? { viewer: viewerDid } : {}) 168 }) 169 : { output: { items: [] }, stats: {} as Stats }; 170 const reposByDid = new Map( 171 enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item]) 172 ); 173 const resolved = await Promise.all( 174 items.map(async (item): Promise<StarData | null> => { 175 const value = item.value as ShTangledFeedStar.Main; 176 const subject = value.subject; 177 if (subject && "did" in subject && subject.did) { 178 const repo = reposByDid.get(subject.did); 179 if (!repo) return null; 180 const ownerDid = didFromUri(repo.uri); 181 const owner = await cache.resolve(ownerDid).catch(() => null); 182 return { 183 kind: "repo", 184 uri: item.uri, 185 createdAt: value.createdAt, 186 repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats) 187 }; 188 } 189 if (subject && "uri" in subject && subject.uri) { 190 const ownerDid = didFromUri(subject.uri); 191 const owner = await cache.resolve(ownerDid).catch(() => null); 192 return { 193 kind: "string", 194 uri: item.uri, 195 createdAt: value.createdAt, 196 ownerHandle: owner?.handle ?? ownerDid, 197 rkey: rkeyFromUri(subject.uri) 198 }; 199 } 200 return null; 201 }) 202 ); 203 return resolved.filter((star): star is StarData => star !== null); 204}; 205 206export const load: PageLoad = async (event) => { 207 const parent = await event.parent(); 208 const tab = normalizeTab(event.url.searchParams.get("tab")); 209 210 if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } }; 211 212 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 213 const did = parent.identity.did as Did; 214 const handle = parent.identity.handle; 215 216 try { 217 switch (tab) { 218 case "repos": { 219 const q = event.url.searchParams.get("q")?.trim(); 220 const viewerDid = parent.auth?.did; 221 const viewerEnrich = viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT]; 222 if (!q) { 223 const enriched = await enrich<RecordList<RepoRecord>>(ctx, { 224 xrpc: "sh.tangled.repo.listRepos", 225 params: { subject: did, limit: PAGE_LIMIT }, 226 enrich: viewerEnrich, 227 ...(viewerDid ? { viewer: viewerDid } : {}) 228 }); 229 return { 230 tab, 231 repos: enriched.output.items.map((item) => 232 resolveRepoCard(item, handle, enriched.stats) 233 ) 234 }; 235 } 236 const enriched = await enrich<SearchPage>(ctx, { 237 xrpc: "sh.tangled.search.query", 238 params: { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }, 239 enrich: viewerEnrich, 240 ...(viewerDid ? { viewer: viewerDid } : {}) 241 }); 242 return { 243 tab, 244 repos: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats)) 245 }; 246 } 247 case "strings": { 248 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { 249 subject: did, 250 limit: PAGE_LIMIT 251 }); 252 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; 253 } 254 case "followers": { 255 const viewerDid = parent.auth?.did; 256 const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, { 257 xrpc: "sh.tangled.graph.listFollows", 258 params: { subject: did, limit: PAGE_LIMIT }, 259 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 260 ...(viewerDid ? { viewer: viewerDid } : {}) 261 }); 262 const dids = enriched.output.items.map((item) => didFromUri(item.uri)); 263 return { 264 tab, 265 people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) 266 }; 267 } 268 case "following": { 269 const viewerDid = parent.auth?.did; 270 const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, { 271 xrpc: "sh.tangled.graph.listFollowsBy", 272 params: { subject: did, limit: PAGE_LIMIT }, 273 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS, 274 ...(viewerDid ? { viewer: viewerDid } : {}) 275 }); 276 const dids = enriched.output.items.map( 277 (item) => (item.value as ShTangledGraphFollow.Main).subject 278 ); 279 return { 280 tab, 281 people: await resolvePeople(ctx, dids, enriched.stats, viewerDid) 282 }; 283 } 284 case "vouches": { 285 const [incomingPage, outgoingPage] = await Promise.all([ 286 fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }), 287 fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { subject: did, limit: PAGE_LIMIT }) 288 ]); 289 const [incoming, outgoing] = await Promise.all([ 290 resolveVouches(ctx, incomingPage.items, "incoming"), 291 resolveVouches(ctx, outgoingPage.items, "outgoing") 292 ]); 293 const vouches = [...incoming, ...outgoing].sort( 294 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 295 ); 296 return { 297 tab, 298 vouches, 299 isSelf: parent.auth?.did === did, 300 profileHandle: handle 301 }; 302 } 303 case "starred": { 304 const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", { 305 subject: did, 306 limit: PAGE_LIMIT 307 }); 308 return { 309 tab, 310 stars: await resolveStars(ctx, page.items, parent.auth?.did) 311 }; 312 } 313 case "overview": 314 default: { 315 const viewerDid = parent.auth?.did; 316 const enriched = await enrich<RecordList<RepoRecord>>(ctx, { 317 xrpc: "sh.tangled.repo.listRepos", 318 params: { subject: did, limit: PAGE_LIMIT }, 319 enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT], 320 ...(viewerDid ? { viewer: viewerDid } : {}) 321 }); 322 323 const pinnedKeys = parent.profile?.pinnedRepositories ?? []; 324 const byKey = new Map<string, ListItem>(); 325 for (const item of enriched.output.items) { 326 const value = item.value as RepoRecord; 327 if (value.repoDid) byKey.set(value.repoDid, item); 328 byKey.set(item.uri, item); 329 } 330 const pinnedItems = pinnedKeys 331 .map((key) => byKey.get(key)) 332 .filter((item): item is ListItem => item !== undefined); 333 const pinned = pinnedItems.map((item) => resolveRepoCard(item, handle, enriched.stats)); 334 335 return { tab: "overview" as const, overview: { pinned } }; 336 } 337 } 338 } catch (cause) { 339 console.error("Page load error:", cause); 340 toHttpError(cause, "Could not load profile data"); 341 } 342};